October 22nd 2020
Contents
OAuth 2.0 Overview
Building a OAuth client
인터넷 상에서 인증이란, 내가 나임을 증명하는 절차라고 생각할 수 있어요. 간단한 예로, id와 password를 들 수 있습니다. password는 오직 본인만 아는 정보이기 때문에 자신을 증명할 수 있어요.
여러 인증하는 방식이 있다면 이를 악용하려는 행위도 있다는 점을 개발자로서 인지해야 합니다. 기본적으로 우리의 웹 애플리케이션 보안을 위해 고려해 볼 수 있는 부분을 살펴보면 아래와 같은 항목들이 있어요.
클라이언트 - 서버 간 안전한 커뮤니케이션 구축
(개인적 정보가 커뮤니케이션 흐름 중 노출되지 않도록)
비밀번호 복구 기능
(유저가 비밀번호 잊은 경우 재설정 할 수 있도록 안전한 일회성 비밀번호 생성)
보안을 위해 여러 고려해야 할 점이 있지만, 작업 별 서버 또는 클라이언트에서만 하거나, 둘 다에서 진행해야 하는 작업이 있습니다. 예로, 비밀번호나 Man-in-the-middle 작업의 경우 서버와 클라이언트 모두 validation 하는 것이 좋습니다.
OAuth 2.0은 자신이 소유한 리소스에 특정 애플리케이션이 접근할 수 있도록 권한을 위임해 주는 프로토콜입니다. 애플리케이션은 리소스 소유자에서 리소스에 대한 접근 권한을 요청하고, 요청 결과로 토큰을 받습니다. 이 토큰을 이용해 해당 리소스에 접근하는 프로토콜입니다. RFC 스펙에 명시된 정의는 아래와 같습니다.
The Oauth 2.0 authorization framework enables a third-party application to obtain limited access to an HTTP service, either on behalf of a resource owner by orchestrating an approval interaction between the resource owner and the HTTP service, or by allowing the third-party application to obtain access on its own behalf.
어떤 시스템이 소유자 대신 다른 시스템의 보호된 리소스에 접근할 수 있는 제한적 권한을 얻을 수 있도록 해주는 프레임워크입니다.
Oauth에 대해 세부적으로 살펴보기에 앞서, OAuth를 구성하는 요소 정의에 대해 먼저 살펴볼게요.
Resource Owner (리소스 소유자)
API에 접근 권한을 가지고 있으며 위임할 수 있다. 웹 브라우저를 이용한다면, 브라우저 앞에 않아 있는 사람이라 생각하면 쉽다.
Protected Resource
리소스 소유자가 접근할 수 있는 구성 요소로, 주로 웹 API 형태를 띠며 이 API를 통해 읽기, 쓰기 등의 작업을 할 수 있다.
Client
OAuth에서 말하는 클라이언트는 웹 개발에서의 클라이언트와는 조금 다르다. OAuth에서 클라이언트는 Protected Resource를 이용하려는 소프트웨어를 말한다.
OAuth 없이 독립적인 여러 서비스를 연계해 작업하는 것은 매우 어렵습니다.
OAuth 프로토콜은 구성 요소간 섬세하게 정보를 서로 주고 받는 여러 단계로 구성 돼 있습니다. 이 중 중요한 부분은 토큰을 발급하고 발급된 토큰을 사용하는 것입니다. 세부 단계는 환경에 따라 달라질 수 있지만 일반적인 흐름은 아래와 같습니다.
Resource Owner가 Client에게 어떠한 작업을 대신해 달라고 한다.
(예, 메일링 앱으로부터 내 메일을 읽어오세요.)
Authorization Grant란 클라이언트가 사용자를 Authorization Server로 안내 후 코드를 전달 받고, 최종적으로 코드를 토큰과 교환하는 프로세스를 말합니다. 전체적인 플로우는 아래 그림으로 확인할 수 있어요.
예를 들어, 사진을 인화하는 앱이 다른 사진 저장 앱에 접근해 사진을 읽어 와야 한다고 생각해 보아요. 사진 접근을 위해서 사진 저장 앱이 제공하는 API를 이용해야 하고, 클라이언트는 이를 위해 OAuth가 필요할 것입니다. 클라이언트가 자원 접근을 위해 Access token이 필요하면, 리소스 오너를 Authorization Server의 인가 엔드 포인트로 안내합니다.
웹 앱의 경우 HTTP 리다이렉트를 통해 리소스 소유자를 Authorization Server의 엔드 포인트를 전달합니다. 이러한 경우 클라이언트의 응답 내용은 아래와 같을 거에요.
HTTP/1.1 302 Moved Temporarily
x-powered-by: Express
Location: http://localhost:9001/authorize?response_type=code&scope=foo&client _id=oauth-client-1&redirect_uri=http%3A%2F%2Flocalhost%3A9000%2Fcallback& state=Lwt50DDQKUB8U7jtfLQCVGDL9cnmwHH1
Vary: Accept
Content-Type: text/html; charset=utf-8
Content-Length: 444
Date: Fri, 24 Oct 2020 20:50:19 GMT
Connection: keep-alive
위 응답을 받은 웹 브라우저는 Authorization Server에 Get 요청을 보내게 됩니다.
GET /authorize?response_type=code&scope=foo&client_id=oauth-client -1&redirect_uri=http%3A%2F%2Flocalhost%3A9000% 2Fcallback&state=Lwt50DDQKUB8U7jtfLQCVGDL9cnmwHH1 HTTP/1.1
Host: localhost:9001
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:39.0) Gecko/20100101 Firefox/39.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Referer: http://localhost:9000/
Connection: keep-alive
URL 쿼리 파라미터로 클라이언트는 자신을 식별할 수 있는 값과, Scope와 같은 필요한 데이터 범위를 포함해 전달합니다.
사용자가 Authorization Server로 리다이렉트 되면 Authorization Server는 사용자에게 인증을 요구합니다. 이 상황에서 클라이언트는 사용자 인증 정보를 볼 수 없고, 사용자와 인가 서버가 직접 상호작용 합니다. 이는 OAuth가 만들어진 이유기도 합니다. 클라이언트가 배제된 상태이기에 Authorization Server는 다양한 인증 기술을 이용할 수 있습니다.
리소스 소유자(사용자)는 클라이언트를 인가합니다. Authorization Server는 클라이언트가 요청한 Scope에 대해 전체를 거부하거나 일부를 거부하게 할 수 있는 선택지를 주는 방법을 제공하기도 합니다. 여러 Authorization Server는 소유자의 결정 내용을 저장할 수도 있으며, 이후 같은 접근 권한이 이루어 지면 같은 작업을 하지 않게 하기도 합니다.
리소스 소유자가 권한 인가를 마치면 Authorization Server는 사용자를 클라이언트의 rediect_uri로 HTTP 리다이렉트 시킵니다. 그리고 웹 브라우저는 클라이언트에게 아래와 같이 요청을 보냅니다.
GET /callback?code=8V1pr0rJ&state=Lwt50DDQKUB8U7jtfLQCVGDL9cnmwHH1 HTTP/1.1
Host: localhost:9000
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:39.0) Gecko/20100101 Firefox/39.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Referer: http://localhost:9001/authorize?response_type=code&scope=foo&client_ id=oauth-client-1&redirect_uri=http%3A%2F%2Flocalhost%3A9000%2Fcallback& state=Lwt50DDQKUB8U7jtfLQCVGDL9cnmwHH1
Connection: keep-alive
URL 파리미터에서 code를 확인할 수 있는데, 이 code는 리소스 사용자가 클라이언트에게 권한 위임을 했음을 나타내는 일회성 자격 증명 데이터입니다. 또한 state 파라미터를 이용해 자신이 이전에 보낸 것과 동일한지 확인해 해당 요청이 부적절한 요청은 아닌지 확인할 수 있습니다.
Authorization Code를 받은 클라이언트는 해당 코드를 Authorization Server의 토큰 엔드 포인트로 다시 전달합니다. 이 POST 요청 시 클라이언트는 자신을 식별하기 위해 client_id
, client_secret
을 Authorization Server에 함께 전달합니다.
POST /token
Host: localhost:9001
Accept: application/json
Content-type: application/x-www-form-encoded
Authorization: Basic b2F1dGgtY2xpZW50LTE6b2F1dGgtY2xpZW50LXNlY3JldC0x
grant_type=authorization_code& redirect_uri=http%3A%2F%2Flocalhost%3A9000%2Fcallback&code=8V1pr0rJ
Authorization Server에서 토큰 요청에 대한 유효성 검증은 여러 단계로 이루어집니다. 우선 Authorization Header로 전달된 클라이언트 자격 증명 데이터를 확인합니다. 그리고 code를 검증하는데, 어느 클라이언트가 요청한 것인지, 어떤 리소스 사용자가 무엇을 허가한 것인지 등을 확인합니다. 코드가 유효하다고 판단하면, Authorization Server는 토큰을 발급합니다.
HTTP 200 OK
Date: Sun, 24 Oct 2020 21:19:03 GMT
Content-type: application/json
{
“access_token”: “987tghjkiu6trfghjuytrghj”,
“token_type”: “Bearer”
}
토큰을 전달 받은 클라이언트는 이것을 이용해 보호된 리소스에 접근할 수 있습니다. 토큰 전달 시 권장되는 방법은 헤더를 이용하는 것입니다. Protected Resource는 토큰의 유효성을 확인 후 요청에 대한 응답을 보냅니다. 토큰의 유효성을 확인하는 방법에 대해서는 뒤쪽에서 살펴볼게요.
OAuth에 대해 간단히 살펴 보았으며, 이번에는 OAuth 클라이언트를 직접 구현해 보려 합니다. 구현에 앞서 클라이언트와 Authorization Server는 서로에 대해 알 수 있는 방법이 필요합니다. 그래서 클라이언트는 client_id
라고 값으로 식별합니다. 또한, 클라이언트는 자기 자신을 Authorization Server에 인증해야 하기 때문에 client_secret
이라는 값도 필요하게 됩니다. 이 client_id
와 client_secret
은 대게 Authorization Server에서 발급하게 됩니다.
위 내용을 기준으로 클라이언트 설정을 아래와 같이 정리할 수 있습니다.
const client = {
client_id: 'my-client-id',
client_secret: 'super-safe-secret-key',
redirect_uris: ['http://localhost:9000/callback']
};
당연하지만, 클라이언트는 Authorization Server의 Authorization endpoint와 Token endpoint 주소에 대해 알아야 합니다.
const authorizationServer = {
authorizationServer: 'http://localhost:9001/authorize',
tokenEndpoint: 'http://localhost:9001/token'
};
기본적으로 필요한 정보가 준비 되었고, Authorization code grant type 방식의 클라이언트를 만들어 볼게요.
아래와 같은 UI가 있고, 토큰 요청하기
버튼을 클릭하면, 클라이언트는 사용자를 Authorization Server의 인가 엔트리 포인트로 리다이렉트 합니다. 위에서 설정한 클라이언트 및 인가 서버 정보를 기준으로 버튼 클릭 시 Authorization Server로 보낼 URL을 구성해 볼 수 있습니다.
let state = 'some-temporary-state-value';
const authorizeUrl = buildUrl(authServer.authorizationEndpoint, {
response_type: 'code',
client_id: client.client_id,
redirect_uri: client.redirect_uri[0],
state
});
// http://localhost:9001/authorize?response_type=code&client_id=my-client-id&redirect_uri=http%3A%2F%2Flocalhost%3A9000%2Fcallback&state=Y1BnSsVng44yREKIh7Wg7Q3sgPmecrC7
그리고 사용자가 버튼을 클릭하면, 브라우저는 클라이언트 앱의 http://localhost:9000/authorize
로 요청을 하게 되고, 요청을 받은 클라이언트에서 위의 Authorization Server의 인가 엔트리 포인트(http://localhost:9001/authorize)로 리다이렉트 합니다. 실제로는 /authorize
와 같이 외부에 오픈된 경로로 트리거 하지 않고, 애플리케이션의 내부 상태에 따라 트리거 되도록 구현해야 합니다.
res.redirect(authorizeUrl);
리다이렉트 된 사용자는 Authorization Server로부터 해당 클라이언트를 인가할 것인지에 대해 문의를 받습니다.
사용자가 클라리언트를 승인하면 Authorization Server는 리소스 사용자를 클라이언트의 redirect_uri로 리다이렉트 시킵니다. Authorization Server가 사용자를 클라이언트로 리다이렉트 시키며 파리미터로 함께 code를 전달합니다.
사용자의 인가 후 전달 받은 코드를, 클라이언트는 Authorization Server의 토큰 엔드포인트로 전달하며 토큰 요청을 진행합니다.
app.get('/callback', function(req, res) {
const form_data = qs.stringify({
grant_type: 'authorization_code',
code: req.query.code,
redirect_uri: client.redirect_uri[0]
});
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + encodeClientCredientials(client.client_id, client.client_secret)
};
const response = syncRequest('POST', authServer.tokenEndpoint, {
body: form_data,
headers: headers
});
const body = JSON.parse(response.getBody());
access_token = body.access_token;
const scope = body.scope;
res.render('index', { access_token, scope });
});
위 코드와 같이 전달 받은 code를 Authorization Server에 전달하면 토큰을 요청하고 있습니다. 그리고, 클라이언트 credentials을 base64로 인코딩 후 헤더에 추가해 전달합니다. 일반적으로 id와 secret은 단순 아스키 문자라 문제가 없지만, 확장된 문자 셋의 경우 url 인코딩, 디코딩이 온전히 지원되는지 확인해야 한다고 합니다. 그래서 이 인증 데이터도 인코딩을 하는 것이 좋습니다.
연습용으로 토큰 확인을 위해 UI에 access token을 보여 주었지만, 실제 앱에서는 절대 이런식으로 노출이 되면 안 됩니다.